// StatNetSimulator.cs
//
// Implements HomeUX.Components.StatNetSimulator.
//
// See ..\..\Drivers\StatNet\DocFiles\StatNetDriver.htm for simulator usage information.
//

using System;
using System.Diagnostics;
using System.Globalization;
using System.IO.Ports;
using System.Text;
using System.Text.RegularExpressions;
using HomeUX.Utilities;

namespace HomeUX.Components
{

/// <summary>
/// Simulates an Enerzone StatNet thermostat network.
/// </summary>
///
/// <remarks>
/// See <n>StatNetDriver</n> documentation for <r>StatNetSimulator</r> usage information.
/// </remarks>
///
public class StatNetSimulator : VirtualSerialPort
{
    ///////////////////////////////////////////////////////////////////////////////////////////////
    // Private Constants
    //

    /// <summary>
    /// The number of milliseconds to wait before each reply is sent back to the serial port.
    /// Increasing this number slows down the speed of the simulation.
    /// </summary>
    const int LatencyMsec = 1000;

    /// <summary>
    /// The number of milliseconds before the first poll, i.e. the first execution of the
    /// thermostat simulation.
    /// </summary>
    const int FirstPollMsec = 5000;

    /// <summary>
    /// The number between polls, i.e. executions of the thermostat simulation.
    /// </summary>
    const int PollMsec = 5000;

    /// <summary>
    /// Matches a StatNet command.
    /// </summary>
    static readonly Regex CommandRegex = new Regex(@"^SN(\d*) ?([A-Z0-9]*)([\?=])(.*)$");

    ///////////////////////////////////////////////////////////////////////////////////////////////
	// Private State
	//

	/// <summary>
	/// This object is locked before a thread accesses this object, to serialize access to the
	/// object.
	/// </summary>
	object _lock = new object();

	/// <summary>
	/// Serial port read buffer, i.e. data to send to client.
	/// </summary>
	StringBuilder _sendToClient = new StringBuilder(1000);

	/// <summary>
	/// Work queue for scheduling asynchronous operations.
	/// </summary>
	WorkQueue _workQueue;

	/// <summary>
	/// The number of simulated thermostats.
	/// </summary>
	int _unitCount = 1/*default*/;

    /// <summary>
    /// The current simulated outdoor temperatur (used to simulate how
    /// <r>SimulatedThermostat.IndoorTemperature</r> changes when the HVAC system is not running),
    /// in Fahrenheit degrees.
    /// </summary>
    public int _outdoorTemperatureInFahrenheit = 50/*default*/;

    /// <summary>
    /// Identifies a test being performed using this simulator, or 0 if no test is being
    /// performed.
    /// </summary>
    int _test;

    /// <summary>
    /// The number of commands that have been executed.
    /// </summary>
    int _commandsExecuted;

	/// <summary>
	/// The simulated thermostats.  Element <c>i</c> refers to thermostat number <c>i + 1</c>.
	/// </summary>
	SimulatedThermostat[] _thermostats;

	/// <summary>
	/// The next scheduled call to <r>Poll</r>, or <n>null</n> if none.
	/// </summary>
	WorkItem _pollWorkItem;

	///////////////////////////////////////////////////////////////////////////////////////////////
	// Public Methods
	//

	/// <summary>
	/// Creates an instance of this class.
	/// </summary>
	///
	public StatNetSimulator() : this(new string[0])
	{
	}

    /// <summary>
    /// Creates an instance of this class.  Allows simulator arguments to be specified.
    /// </summary>
    ///
    /// <param name="args">Simulator arguments.  See StatNetDriver.htm for details.</param>
    ///
    public StatNetSimulator(string[] args)
    {
        // parse <args>
        foreach (string arg in args)
        {
            try
            {
                string[] argParts = arg.Split('=');
                if (argParts.Length != 2)
                    throw new ArgumentException();
                string argName = argParts[0];
                string argValue = argParts[1];
                switch (argName)
                {
                case "Units":
                    _unitCount = System.Xml.XmlConvert.ToInt32(argValue);
                    if (_unitCount <= 0)
                        throw new ArgumentException();
                    break;
                case "OutTemp":
                    _outdoorTemperatureInFahrenheit = System.Xml.XmlConvert.ToInt32(argValue);
                    break;
                case "Test":
                    _test = System.Xml.XmlConvert.ToInt32(argValue);
                    break;
                default:
                    throw new ArgumentException();
                }
            }
            catch (Exception ex)
            {
                if ((ex is FormatException) || (ex is OverflowException) ||
                    (ex is ArgumentException))
                {
                    throw new VirtualSerialPortException("Invalid argument: {0}", arg);
                }
                else
                    throw;
            }
        }
    }

	///////////////////////////////////////////////////////////////////////////////////////////////
	// IDisposable Overrides
	//

    /// <summary>
    /// Implements <n>IDisposable.Dispose</n>.
    /// </summary>
    ///
    public override void Dispose()
    {
		lock (_lock)
		{
			base.Dispose();
			if (_workQueue != null)
			{
				_workQueue.Dispose();
				_workQueue = null;
			}
		}
    }

	///////////////////////////////////////////////////////////////////////////////////////////////
	// VirtualSerialPort Overrides
	//

    /// <summary>
    /// Overrides <r>VirtualSerialPort.SupportsLineBasedTextMode</r>.
    /// </summary>
    ///
	public override bool SupportsLineBasedTextMode
	{
		// this virtual serial port only supports line-based text mode
		get
		{
			return true;
		}
	}

    /// <summary>
    /// Overrides <r>VirtualSerialPort.NewLine</r>.
    /// </summary>
    ///
    public override string NewLine
	{
		// note that NewLine refers to the line terminator that the device expects to receive, not
		// the terminator it necessarily sends; in the case of StatNet, it expects to receive CR but
		// sends CRLF
		get
		{
            return "\r";
		}
        set
        {
            if (value != "\r")
            {
                throw new InvalidOperationException(
					"StatNet expects lines it receives to be terminated by CR");
			}
        }
	}

    /// <summary>
    /// Overrides <r>VirtualSerialPort.Open</r>.
    /// </summary>
    ///
    public override void Open()
    {
        lock (_lock)
        {
			// initialize state
            _thermostats = new SimulatedThermostat[_unitCount];

            // create some fake state
            var random = new Random(0);
            for (int id = 1; id <= _unitCount; id++)
            {
                // set <thermostat> to a new simulated thermostat, with some semi-random state
                const int temp = 72; // typical room temperature in Fahrenheit
                var thermostat = new SimulatedThermostat()
                {
					Id = id,
                    IndoorTemperature = temp + random.Next(11) - 5,
                    CoolingSetPoint = temp + random.Next(11) - 3,
                    Mode = (ThermostatMode)((id - 1) % ((int)ThermostatMode.MaxValue + 1)),
                    IsFanOn = ((id % 1) == 0)
                };
                thermostat.HeatingSetPoint = // must be 2+ degrees BELOW cooling set point
                    thermostat.CoolingSetPoint + random.Next(3) - 6;

                // initialize <thermostat.HvacRequest>
                UpdateHvacRequest(thermostat, false);

                // enable all notifications by default
                for (NotificationType notificationType = NotificationType.MinValue;
                     notificationType <= NotificationType.MaxValue;
                     notificationType++)
                {
                    thermostat.EnableNotification(notificationType, true);
                }

                // store into <_thermostats>
                _thermostats[id - 1] = thermostat;
            }

			// create a work queue for queueing events
			_workQueue = new WorkQueue("StatNetSimulator");

			// debugging feedback
			Trace("Initialized");

            // misbehave if simulator argument Test=1 was specified
            if (_test == 1)
            {
                _sendToClient.Append("RandomJunk\r\nFromStatNetSimulator");
            }

            // schedule the first call to Poll
            _pollWorkItem = _workQueue.Add(FirstPollMsec, Poll);
        }
    }

    /// <summary>
    /// Overrides <r>VirtualSerialPort.ReadExisting</r>.
    /// </summary>
    ///
    public override string ReadExisting()
	{
		lock (_lock)
		{
			string text = _sendToClient.ToString();
			_sendToClient.Length = 0;
			return text;
		}
	}

    /// <summary>
    /// Overrides <r>VirtualSerialPort.WriteLine</r>.
    /// </summary>
    /// 
    /// <param name="text">See <r>VirtualSerialPort.WriteLine</r>.</param>
    ///
    public override void WriteLine(string text)
	{
		// client sent us a command (<text>)
		lock (_lock)
		{
            Trace("Received command: {0}", text);
            ExecuteStatNetCommand(text);
		}
	}

	///////////////////////////////////////////////////////////////////////////////////////////////
	// Private Properties & Methods
	//

	/// <summary>
	/// Executes an StatNet command sent from the client, after a short delay.
	/// </summary>
	///
	/// <param name="command">The StatNet command to execute, not including the terminating carriage
	/// 	return.  For example, "SN?".</param>
	///
    void ExecuteStatNetCommand(string command)
    {
        _workQueue.Add(500, delegate
        {
            try
            {
                ExecuteStatNetCommand2(command);
            }
            catch (StatNetInvalidCommandException ex)
            {
                // if the command wasn't understood, behave the way the real hardware behaves,
                // i.e. ignore the command -- but we also trace the exception message
                Trace(ex.Message);
            }
        });
    }
    
    /// <summary>
    /// Executes an StatNet command sent from the client.
    /// </summary>
    ///
    /// <param name="command">The StatNet command to execute, not including the terminating carriage
    /// 	return.  For example, "SN?".</param>
    ///
    void ExecuteStatNetCommand2(string command)
	{
        // do nothing if this object was disposed
        if (IsDisposed)
            return;

		lock (_lock)
		{
			// keep track of the number of commands that have been executed
			_commandsExecuted++;

			// convert the command to uppercase to "normalize" it for parsing
			command = command.ToUpper();

			// perform the initial echo of <command> (without a newline) --
			// this happens even if the command is invalid
            if ((_test == 1) && (_commandsExecuted == 1) && (command.Length >= 2))
            {
                // in test 1 mode, we insert a notification before the command result
                if (_thermostats.Length > 0)
                    SendHvacNotification(_thermostats[0]);
            }

            // if valid, <command> should have the general form "SN[<thermostatId>]
			// [<propertyName>]{?|=}<propertyValue>" -- parse it into those variables, and parse
			// the "?" or "=" into <propertyOperator>
            Match match = CommandRegex.Match(command);
            if (!match.Success)
                throw new StatNetInvalidCommandException("Unrecognized command: {0}", command);
            int thermostatId; // 1..<_thermostats.Length>; 0 means "all thermostats"
            string propertyName; // String.Empty means "no command name"
            string propertyOperator; // "?" or "="
            string propertyValue; // String.Empty means "no property value"
            try
            {
                // parse <thermostatId>
                string str = match.Groups[1].Value;
                if (str.Length > 0)
                    thermostatId = int.Parse(str, CultureInfo.InvariantCulture);
                else
                    thermostatId = 0;

                // parse <propertyName>
                propertyName = match.Groups[2].Value;

                // parse <propertyOperator>
                propertyOperator = match.Groups[3].Value;

                // parse <propertyValue>
                propertyValue = match.Groups[4].Value;
            }
            catch (Exception ex)
            {
                if ((ex is FormatException) || (ex is OverflowException))
                    throw new StatNetInvalidCommandException("Invalid command: {0}", command);
                else
                    throw;
            }

            // execute the parsed command
            if (thermostatId == 0)
            {
                // execute the command for each thermostat
                if (_thermostats.Length > 0)
                {
                    ExecuteThermostatCommand(1, propertyName, propertyOperator[0], propertyValue,
                        true);
                }
            }
            else
            if ((thermostatId > 0) && (thermostatId <= _thermostats.Length))
            {
                // execute the command for the specified thermostat
                ExecuteThermostatCommand(thermostatId, propertyName, propertyOperator[0],
                    propertyValue, false);
            }
            else
                throw new StatNetInvalidCommandException("Invalid thermostat ID {0}", thermostatId);

		}
	}

    /// <summary>
    /// Executes a parsed thermostat command.
    /// </summary>
    ///
    /// <param name="thermostatId">The 1-based ID of the thermostat that the command applies to.
    ///     </param>
    ///
    /// <param name="propertyName">The name of the thermostat property to query or set, or
    ///     <n>String.Empty</n> if the command is "SN?".</param>
    ///
    /// <param name="propertyOperator">'?' to query the thermostat property, '=' to set it.
    ///     </param>
    ///
    /// <param name="propertyValue">The value to set the thermostat property to, if applicable.
    ///     </param>
    ///
    /// <param name="queueNextThermostatCommand">Indicates if this command is being applied to all
    ///     thermostats.  If so, then if this command completes successfully the same command will
    ///     be queued asynchronously to be performed on the next thermostat (if any).</param>
    ///
    void ExecuteThermostatCommand(int thermostatId, string propertyName, char propertyOperator,
        string propertyValue, bool queueNextThermostatCommand)
    {
        // do nothing if this object was disposed
        if (IsDisposed)
            return;

        // we need to lock() because we may be called due to _workQueue.Add (below), which won't
		// perform a lock()
		lock (_lock)
		{
            try
            {
                // set <thermostat> to the SimulatedThermostat specified by <thermostatId>
                SimulatedThermostat thermostat = _thermostats[thermostatId - 1];

                // execute the command
			    switch (propertyName)
			    {

			    case "":

				    // e.g. "SN?" (used to list all thermostats)
				    if (propertyOperator == '?')
					    WriteToClient("SN{0}\r", thermostatId);
				    else
                        throw new StatNetInvalidCommandException("SN= is not valid");
				    break;

                case "SCALE":

                    // e.g. "SN1 SCALE=F" (set scale to Faharenheit or Celcius)
                    if (propertyOperator == '=')
                    {
                        if ((propertyValue == "F") || (propertyValue == "C"))
                            thermostat.Scale = propertyValue[0];
                        else
                        {
                            throw new StatNetInvalidCommandException("Scale not valid: {0}",
                                propertyValue);
                        }
                        Trace("Thermostat {0}: Scale set to {1}", thermostatId,
                            ((thermostat.Scale == 'C') ? "Celcius" : "Fahrenheit"));
                    }
                    WriteToClient("SN{0} SCALE={1}\r", thermostatId, thermostat.Scale);
                    break;

                case "C1":
                case "C2":
                case "C3":
                case "C4":
                case "C5":
                case "C6":
                case "C7":
                case "C8":

                    // e.g. "SN1 C1=ON" (enable/disable notifications)
                    var notificationType = (NotificationType)ParseInt(propertyName.Substring(1),
                        (int)NotificationType.MinValue, (int)NotificationType.MaxValue);
                    if (propertyOperator == '=')
                    {
                        if (propertyValue == "ON")
						{
                            thermostat.EnableNotification(notificationType, true);
							Trace("Thermostat {0}: Notification {1} ({2}) enabled", thermostatId,
								(int)notificationType, notificationType);
						}
                        else
                        if (propertyValue == "OFF")
						{
                            thermostat.EnableNotification(notificationType, false);
							Trace("Thermostat {0}: Notification {1} ({2}) disabled", thermostatId,
								(int)notificationType, notificationType);
						}
                        else
                        {
                            throw new StatNetInvalidCommandException(
                                "Expected 'ON' or 'OFF', found: {0}", propertyValue);
                        }
                    }
                    WriteToClient("SN{0} C{1}={2}\r", thermostatId, (int)notificationType,
                        (thermostat.IsNotificationEnabled(notificationType) ? "ON" : "OFF"));
                    break;

                case "T":

                    if (propertyOperator == '=')
                    {
                        throw new StatNetInvalidCommandException(
                            "Actual temperature cannot be set");
                    }
                    WriteToClient("SN{0} T={1}{2}\r", thermostatId, thermostat.IndoorTemperature,
                        thermostat.Scale);
                    break;

                case "M":

                    // e.g. "SN1 MODE=AUTO"
                    if (propertyOperator == '=')
                    {
                        switch (propertyValue)
                        {
                        case "OFF":
                            thermostat.Mode = ThermostatMode.Off;
                            break;
                        case "AUTO":
                            thermostat.Mode = ThermostatMode.Auto;
                            break;
                        case "COOL":
                            thermostat.Mode = ThermostatMode.Cool;
                            break;
                        case "HEAT":
                            thermostat.Mode = ThermostatMode.Heat;
                            break;
                        case "E":
                            thermostat.Mode = ThermostatMode.EmergencyHeat;
                            break;
                        default:
                            throw new StatNetInvalidCommandException(
                                "Unknown thermostat mode: {0}", propertyValue);
                        }
                        Trace("Thermostat {0}: Mode set to {1}", thermostatId, thermostat.Mode);
                    }
                    WriteToClient("SN{0} MODE={1}\r", thermostatId, thermostat.ModeAsString);
                    break;

                case "SH":

                    // e.g. "SN1 SH=70" (set heating set point)
                    if (propertyOperator == '=')
                    {
                        if (propertyValue.EndsWith("C") || propertyValue.EndsWith("F"))
                        {
                            throw new StatNetInvalidCommandException(
                                "Simulator doesn't support 'C' or 'F' suffix on 'SH' command");
                        }
                        thermostat.HeatingSetPoint = ParseInt(propertyValue, -999, 999);
                        Trace("Thermostat {0}: Heating set point set to {1}{2}", thermostatId,
                            thermostat.HeatingSetPoint, thermostat.Scale);
                    }
                    WriteToClient("SN{0} SH={1}{2}\r", thermostatId, thermostat.HeatingSetPoint,
                        thermostat.Scale);
                    break;

                case "SC":

                    // e.g. "SN1 SC=74" (set cooling set point)
                    if (propertyOperator == '=')
                    {
                        if (propertyValue.EndsWith("C") || propertyValue.EndsWith("F"))
                        {
                            throw new StatNetInvalidCommandException(
                                "Simulator doesn't support 'C' or 'F' suffix on 'SC' command");
                        }
                        thermostat.CoolingSetPoint = ParseInt(propertyValue, -999, 999);
                        Trace("Thermostat {0}: Cooling set point set to {1}{2}", thermostatId,
                            thermostat.CoolingSetPoint, thermostat.Scale);
                    }
                    WriteToClient("SN{0} SC={1}{2}\r", thermostatId, thermostat.CoolingSetPoint,
                        thermostat.Scale);
                    break;

                case "HVAC":

                    if (propertyOperator == '=')
                    {
                        throw new StatNetInvalidCommandException(
                            "HVAC relay states cannot be set");
                    }
                    SendHvacNotification(thermostat);
                    break;

                default:

                    throw new StatNetInvalidCommandException("Unknown property: {0}",
                        propertyName);
			    }

			    if (queueNextThermostatCommand && (thermostatId < _thermostats.Length))
			    {
				    _workQueue.Add(250, delegate
				    {
					    ExecuteThermostatCommand(thermostatId + 1, propertyName, propertyOperator,
						    propertyValue, true);
				    });
			    }
            }
            catch (StatNetInvalidCommandException ex)
            {
                // if the command wasn't understood, behave the way the real hardware behaves,
                // i.e. ignore the command -- but we also trace the exception message
                Trace(ex.Message);
            }
        }
    }

    /// <summary>
	/// Called periodically to perform the next simulated operation -- the type of operation that's
    /// not initiated by a command from the serial port.
    /// <summary>
	///
	void Poll()
	{
		// update <_pollWorkItem> to reflect that there's now no queued call to this method
		_pollWorkItem = null;

        // do nothing further if this object was disposed
        if (IsDisposed)
            return;

        // simulate the operation of each thermostat
        foreach (SimulatedThermostat thermostat in _thermostats)
            PollThermostat(thermostat);

        // queue the next call to this method
        _pollWorkItem = _workQueue.Add(PollMsec, Poll);
    }

    /// <summary>
	/// Called periodically to perform the next simulated operation specific to a thermostat -- the
    /// type of operation that's not initiated by a command from the serial port.
    /// <summary>
	///
    /// <param name="thermostat">The thermostat.</param>
    ///
    void PollThermostat(SimulatedThermostat thermostat)
    {
        if (thermostat.HvacRequest == HvacRequest.Heat)
        {
            // the furnace is currently running, which affects the room temperature
            if (thermostat.IndoorTemperature >= thermostat.HeatingSetPoint)
            {
                Trace("Thermostat {0}: Reached heating set point({1}{2}); turning off heating",
                    thermostat.Id, thermostat.HeatingSetPoint, thermostat.Scale);
                thermostat.HvacRequest = HvacRequest.None;
                SendHvacNotification(thermostat);
            }
            else
            {
                Trace("Thermostat {0}: Furnace is warming the room", thermostat.Id);
                thermostat.IndoorTemperature++;
                SendTNotification(thermostat);
            }
        }
        else
        if (thermostat.HvacRequest == HvacRequest.Cool)
        {
            // air conditioning is currently running, which affects the room temperature
            if (thermostat.IndoorTemperature <= thermostat.CoolingSetPoint)
            {
                Trace("Thermostat {0}: Reached cooling set point ({1}{2}); turning off air conditioning",
                    thermostat.Id, thermostat.CoolingSetPoint, thermostat.Scale);
                thermostat.HvacRequest = HvacRequest.None;
                SendHvacNotification(thermostat);
            }
            else
            {
                Trace("Thermostat {0}: Air conditioning is cooling the room", thermostat.Id);
                thermostat.IndoorTemperature--;
                SendTNotification(thermostat);
            }
        }
        else
        if (thermostat.HvacRequest == HvacRequest.None)
        {
            // neither furnace nor air conditioning is currently running -- the room temperature
            // is affected by the outdoor temperature...

            // set <outdoorTemperature> to the outdoor temperature in the units of
            // <thermostat.Scale>
            int outdoorTemperature;
            if (thermostat.Scale == 'F')
                outdoorTemperature = _outdoorTemperatureInFahrenheit;
            else
                outdoorTemperature = ((_outdoorTemperatureInFahrenheit - 32) * 5) / 9;

            // the outdoor temperature affects the indoor temperature
            Trace("Thermostat {0}: The outdoor temperature ({1}{2}) is affecting the indoor temperature",
                thermostat.Id, outdoorTemperature, thermostat.Scale);
            if (thermostat.IndoorTemperature < outdoorTemperature)
            {
                thermostat.IndoorTemperature++;
                SendTNotification(thermostat);
            }
            else
            if (thermostat.IndoorTemperature > outdoorTemperature)
            {
                thermostat.IndoorTemperature--;
                SendTNotification(thermostat);
            }

            // turn on the furnace if we're in Heat/EmergencyHeat mode and the room temperature
            // has dropped below the heating set point
            if (((thermostat.Mode == ThermostatMode.Heat) ||
                 (thermostat.Mode == ThermostatMode.EmergencyHeat)) &&
                (thermostat.IndoorTemperature < thermostat.HeatingSetPoint))
            {
                Trace("Thermostat {0}: Reached heating set point ({1}{2}); turning on heating",
                    thermostat.Id, thermostat.HeatingSetPoint, thermostat.Scale);
                thermostat.HvacRequest = HvacRequest.Heat;
                SendHvacNotification(thermostat);
            }

            // turn on air conditioning if we're in Cool mode and the room temperature has risen
			// above the cooling set point
            if ((thermostat.Mode == ThermostatMode.Cool) &&
                (thermostat.IndoorTemperature > thermostat.CoolingSetPoint))
            {
                Trace("Thermostat {0}: Reached cooling set point ({1}{2}); turning on air conditioning",
                    thermostat.Id, thermostat.CoolingSetPoint, thermostat.Scale);
                thermostat.HvacRequest = HvacRequest.Cool;
                SendHvacNotification(thermostat);
            }
        }
	}

    /// <summary>
    /// Updates <r>SimulatedThermostat.HvacRequest</r> based on other state in
    /// <r>SimulatedThermostat</r>, and optionally sends an "HVAC" notification as needed if
    /// <s>SimulatedThermostat.HvacRequest</s> has changed.
    /// </summary>
    ///
    /// <param name="thermostat">The <r>SimulatedThermostat</r> to update.</param>
    ///
    /// <param name="sendNotifications">Specifies whether to send notifications as needed to the
    ///     client based on what this method does.</param>
    ///
	void UpdateHvacRequest(SimulatedThermostat thermostat, bool sendNotifications)
	{
        // if conditions are correct, request heat from the HVAC system
        if ((thermostat.IndoorTemperature < thermostat.HeatingSetPoint) &&
            ((thermostat.Mode == ThermostatMode.Auto) ||
             (thermostat.Mode == ThermostatMode.Heat) ||
             (thermostat.Mode == ThermostatMode.EmergencyHeat)))
        {
            if (thermostat.HvacRequest != HvacRequest.Heat) // not already requesting heating
			{
				thermostat.HvacRequest = HvacRequest.Heat;
				if (sendNotifications)
					SendHvacNotification(thermostat);
			}
            return;
        }

        // otherwise, if conditions are correct, request air conditioning from the HVAC system
        if ((thermostat.IndoorTemperature > thermostat.CoolingSetPoint) &&
            ((thermostat.Mode == ThermostatMode.Auto) ||
             (thermostat.Mode == ThermostatMode.Cool)))
        {
            if (thermostat.HvacRequest != HvacRequest.Cool) // not already requesting cooling
			{
				thermostat.HvacRequest = HvacRequest.Cool;
				if (sendNotifications)
					SendHvacNotification(thermostat);
			}
            return;
        }

		// otherwise, request neither heating nor cooling from the HVAC system
		if (thermostat.HvacRequest != HvacRequest.None) // not already requesting nothing
		{
			thermostat.HvacRequest = HvacRequest.None;
			if (sendNotifications)
				SendHvacNotification(thermostat);
			return;
		}
	}

    /// <summary>
    /// Sends an "HVAC" notification to the client, which indicates that the state of the relays
	/// on a given thermostat has changed.
    /// </summary>
	///
    /// <param name="thermostat">The <r>SimulatedThermostat</r> to send a notification on behalf
	/// 	of.</param>
	///
    void SendHvacNotification(SimulatedThermostat thermostat)
    {
        /* Examples of "HVAC" notifications send by real hardware:
            SN1 HVAC=G+Y1-W1-Y2-W2-B-O+
            SN1 HVAC=G+Y1-W1-Y2-W2-B+O-
            SN1 HVAC=G+Y1+W1-Y2-W2-B-O+
            SN1 HVAC=G+Y1+W1-Y2-W2-B+O-
            SN2 HVAC=G+Y1-W1-Y2-W2-B-O+
            SN2 HVAC=G+Y1+W1-Y2-W2-B-O+
            SN4 HVAC=G-Y1-W1-Y2-W2-B-O+
            SN4 HVAC=G-Y1-W1-Y2-W2-B+O-
            SN4 HVAC=G+Y1+W1-Y2-W2-B-O+
        */
        WriteToClient("SN{0} HVAC=G{1}Y1{2}W1{3}Y2-W2-B{4}O{5}" + NewLine,
            thermostat.Id,
            PlusOrMinus(thermostat.IsFanOn),
            PlusOrMinus(thermostat.HvacRequest == HvacRequest.Cool),
            PlusOrMinus(thermostat.HvacRequest == HvacRequest.Heat),
            PlusOrMinus((thermostat.Mode == ThermostatMode.Heat) ||
                (thermostat.Mode == ThermostatMode.EmergencyHeat)),
            PlusOrMinus(thermostat.Mode == ThermostatMode.Cool));
    }

    /// <summary>
    /// Sends a "T" notification to the client, which indicates that the room temperature has
	/// changed at the location of a given thermostat has changed.
    /// </summary>
	///
    /// <param name="thermostat">The <r>SimulatedThermostat</r> to send a notification on behalf
	/// 	of.</param>
	///
    void SendTNotification(SimulatedThermostat thermostat)
    {
        WriteToClient("SN{0} T={1}{2}" + NewLine, thermostat.Id, thermostat.IndoorTemperature,
			thermostat.Scale);
	}

    /// <summary>
    /// Converts <n>true</n> and <n>false</n> to '+' and '-', respectively.
    /// </summary>
    ///
    /// <param name="value">The input <n>bool</n> value.</param>
    /// 
    /// <returns>'+' or '-'.</returns>
    ///
	char PlusOrMinus(bool value)
	{
		return value ? '+' : '-';
	}

    /// <summary>
    /// Parses an integer from a string, using <n>CultureInfo.InvariantCulture</n>, and checks
    /// that the integer is in a specific range.
    /// </summary>
    ///
    /// <param name="stringValue">The value to parse.</param>
    ///
    /// <param name="minValue">The minimum allowed value.</param>
    ///
    /// <param name="maxValue">The maximum allowed value.</param>
    ///
    /// <exception cref="StatNetInvalidCommandException">
    /// <pr>stringValue</pr> isn't a valid integer, or isn't in the correct range.
    /// </exception>
    ///
	int ParseInt(string stringValue, int minValue, int maxValue)
	{
		try
		{
			int value = int.Parse(stringValue, CultureInfo.InvariantCulture);
			if ((value < minValue) || (value > maxValue))
			{
				throw new StatNetInvalidCommandException(
					"Out of range ({0} to {1}, inclusive): {2}",
					minValue, maxValue, value);
			}
			return value;
		}
		catch (Exception ex)
		{
			if ((ex is FormatException) || (ex is OverflowException))
				throw new StatNetInvalidCommandException("Not an integer: {0}", stringValue);
			else
				throw;
		}
	}

	/// <summary>
	/// Formats a string and sends it string to the client asynchronously.  The client will receive
	/// the string on the virtual serial port.
	/// </summary>
	/// 
	/// <param name="format">The error message format string.  If <pr>args</pr> is empty,
	/// 	<pr>format</pr> is used without formatting.</param>
	///
	/// <param name="args">Formatting arguments.</param>
	///
	void WriteToClient(string format, params object[] args)
	{
		lock (_lock)
		{
			WriteToClient((args.Length == 0) ? format : String.Format(format, args));
		}
	}

	/// <summary>
	/// Sends a string to the client asynchronously.  The client will receive the string on the
	/// virtual serial port.
	/// </summary>
	///
	void WriteToClient(string text)
	{
		lock (_lock)
			_sendToClient.Append(text);
		_workQueue.Add(delegate
		{
            FireDataReceived(SerialData.Chars);
		});
	}

    /// <summary>
    /// Converts an output gain level (in decibels, or -1 for mute) to a human-readable string.
    /// </summary>
    ///
    /// <param name="gain">The output gain level.</param>
    ///
    string FormatOutputGain(decimal gain)
    {
        if (gain < 0)
            return "mute";
        else
            return String.Format("{0} dB", gain);
    }

	/// <summary>
	/// Writes a formatted string to the debug output window.
	/// </summary>
    ///
    /// <param name="format">Format string.</param>
	///
    /// <param name="args">Formatting arguments.</param>
    ///
	static void Trace(string format, params object[] args)
	{
		// static -- no lock required
		Util.Trace("StatNetSimulator: {0}", Util.Format(format, args));
	}

	///////////////////////////////////////////////////////////////////////////////////////////////
	// Nested Types
	//

	/// <summary>
	/// Indicates an invalid StatNet command.
	/// </summary>
	///
	class StatNetInvalidCommandException : Exception
	{
        /// <summary>
        /// Initializes an instance of this class.  Formats an exception message.
        /// </summary>
        ///
        /// <param name="format">Format string.  If no <pr>args</pr> are supplied,
        ///     <pr>format</pr> is used as-is, without formatting.</param>
        ///
        /// <param name="args">Formatting arguments.</param>
        ///
        public StatNetInvalidCommandException(string format, params object[] args) :
            base(Util.Format(format, args))
        {
        }
	}

    /// <summary>
    /// Represents a simulated thermostat.
    /// </summary>
    ///
	[DebuggerDisplay("SimulatedThermostat {Id}")]
	class SimulatedThermostat
	{
		/// <summary>
		/// The 1-based identifier of the simulated thermostat.
		/// </summary>
		public int Id;

		/// <summary>
		/// The current actual temperature at the thermostat, in the units specified by
        /// <r>Scale</r>.
		/// </summary>
		public int IndoorTemperature;

		/// <summary>
		/// The mode of a thermostat.
		/// </summary>
		public ThermostatMode Mode;

		/// <summary>
		/// The mode of a thermostat, represented in the string form (e.g. "OFF", "AUTO", etc.)
		/// used by the hardware.
		/// </summary>
        public string ModeAsString
        {
            get
            {
                switch (Mode)
                {
				case ThermostatMode.Off:
					return "OFF";
				case ThermostatMode.Auto:
					return "AUTO";
				case ThermostatMode.Cool:
					return "COOL";
				case ThermostatMode.Heat:
					return "HEAT";
				case ThermostatMode.EmergencyHeat:
					return "E";
				default: // internal error -- shouldn't occur
					return "?";
                }
            }
        }

        /// <summary>
	    /// Indicates if the thermostat is requesting cooling or heating from the HVAC system.
        /// </summary>
        public HvacRequest HvacRequest;

		/// <summary>
		/// The heating set point, in the units specified by <r>Scale</r>.
		/// </summary>
		public int HeatingSetPoint;

		/// <summary>
		/// The cooling set point, in the units specified by <r>Scale</r>.
		/// </summary>
		public int CoolingSetPoint;

		/// <summary>
		/// Indicates if the fan setting is on.
		/// </summary>
		public bool IsFanOn;

        /// <summary>
        /// The units of the thermostat, either 'F' for Fahrenheit or 'C' for Celcius.
        /// </summary>
        public char Scale = 'F';

        /// <summary>
        /// Element <c>i</c> indicates whether notification <c>i + 1</c> is enabled or disabled
        /// for this thermostat.
        /// </summary>
        bool[] _notificationsEnabled = new bool[(int)NotificationType.MaxValue + 1];

        /// <summary>
        /// Checks if a given type of notification is enabled for this thermostat.
        /// </summary>
        ///
        /// <param name="notificationType">The notification type.</param>
        ///
        public bool IsNotificationEnabled(NotificationType notificationType)
        {
            return _notificationsEnabled[(int)notificationType - 1];
        }

        /// <summary>
        /// Enables or disables notifications of a given type for this thermostat.
        /// </summary>
        ///
        /// <param name="notificationType">The notification type.</param>
        ///
        /// <param name="enable"><n>true</n> to enable notifications, <n>false</n> to disable
        ///     them.</param>
        ///
        public void EnableNotification(NotificationType notificationType, bool enable)
        {
            _notificationsEnabled[(int)notificationType - 1] = enable;
        }
    }

	/// <summary>
	/// The mode of a thermostat.
	/// </summary>
	///
	enum ThermostatMode
	{
		/// <summary>
		/// The thermostat is off.
		/// </summary>
		Off,

		/// <summary>
		/// The thermostat is in automatic cooling/heating mode.
		/// </summary>
		Auto,

		/// <summary>
		/// The thermostat is in cooling mode.
		/// </summary>
		Cool,

		/// <summary>
		/// The thermostat is in heating mode.
		/// </summary>
		Heat,

		/// <summary>
		/// The thermostat is in emergency heat mode.
		/// </summary>
		EmergencyHeat,

        /// <summary>
        /// The maximum value of this enumeration.
        /// </summary>
        MaxValue = EmergencyHeat
	}

	/// <summary>
	/// Indicates if the thermostat is requesting cooling or heating from the HVAC system.  Note
	/// that this simulation only supports single-stage cooling and heating.
	/// </summary>
	///
	enum HvacRequest
	{
        /// <summary>
        /// The thermostat is requesting neither cooling nor heating from the HVAC system.
        /// </summary>
		None,

        /// <summary>
        /// The thermostat is requesting cooling from the HVAC system.
        /// </summary>
		Cool,

        /// <summary>
        /// The thermostat is requesting heating from the HVAC system.
        /// </summary>
		Heat,
	}

    /// <summary>
    /// Specifies a type of notification from the hardware.
    /// </summary>
	///
    enum NotificationType
    {
        /// <summary>
        /// The minimum value of this enumeration.
        /// </summary>
        MinValue = 1,

        /// <summary>
        /// A notification about HVAC control circuits (relays).
        /// </summary>
		HvacRelays = 1,
        
        /// <summary>
        /// A notification about actual temperature at the thermostat.
        /// </summary>
        Temperature = 2,

        /// <summary>
        /// A notification about outdoor temperature.
        /// </summary>
        OutdoorTemperature = 3,

        /// <summary>
        /// A notification about contact closures.
        /// </summary>
        ContactClosures = 4,

        /// <summary>
        /// A notification about thermostat set points (heating and cooling).
        /// </summary>
        ThermostatSetPoints = 5,

        /// <summary>
        /// A notification about the user HOLD function.
        /// </summary>
        UserHoldFunction = 6,

        /// <summary>
        /// A notification about <r>ThermostatMode</r>.
        /// </summary>
        ThermostatMode = 7,

        /// <summary>
        /// A notification about the thermostat fan switch.
        /// </summary>
        ThermostatFanSwitch = 8,

        /// <summary>
        /// The maximum value of this enumeration.
        /// </summary>
        MaxValue = 8
    };
}

}

